Conversation
|
We should check with upstream datafusion. @alamb mentioned that the group by stuff is being rewritten to be more efficient and avoid this giant interim batch in the first place. I'd like to make sure if we tackle this in Comet it's a generalizable solution and not special-casing a hack. Also at a glance the comments seem huge and redundant. |
| "because the cap is only reachable for very large per-partition group cardinalities; " + | ||
| "enable it when you see the offset-overflow error.") | ||
| .booleanConf | ||
| .createWithDefault(true) |
There was a problem hiding this comment.
The PR description says that this is an experimental feature and is disabled by default. Is it intentional that it is enabled by default here?
| super.sparkConf.set(SQLConf.ANSI_ENABLED.key, "false") | ||
| super.sparkConf | ||
| .set(SQLConf.ANSI_ENABLED.key, "false") | ||
| .set("spark.memory.offHeap.enabled", "false") |
There was a problem hiding this comment.
These changes seem specific to the ignored test, but will impact all of the existing tests?
|
@mbutrovich this is more like a hack right now to support aggregation explosion cases, as a more long term solution we can consider StringView usage later on, or if DataFusion |
| // The test to reproduce `offset overflow` for aggregation queries, when interim data | ||
| // get exploded 100x comparing to initial input size. | ||
| // It is not supposed to run on CI as the test requires significant RAM to succeed | ||
| ignore("CUBE(9) + COUNT(DISTINCT) wide Utf8 keys: useLargeDataTypes preserves correctness") { |
There was a problem hiding this comment.
I don't see any new tests for the functional changes in this PR. Could you add functional tests that use small amounts of data, just to test for correctness?
There was a problem hiding this comment.
The entire CI is passed with useLargeDataTypes enabled.
on CI grade machines we cannot reproduce issue as we run out of memory earlier than hit the offset limit
FWIW we are working on refactoring aggregates here Then I think we will be in position to update the allocation strategy internally (which currently uses large contiguous allocations for group values and aggregates |
Once we support non contiguous allocations, I think we'll be in the position to avoid blowing out the offsets for more than 2GB of string data |
|
(to be clear I doubt we'll have non contiguous allocations for DF 55 - maybe 56) |
Thanks @alamb for the input |
The problem is real:
(DataType::LargeUtf8, DataType::Utf8) | (DataType::LargeBinary, DataType::Binary) => {
Ok(Arc::clone(array))
}This is in The comment explains why the real cast is undesirable in the aggregate case (absolute offsets above Defaulting the config to
What does that cost on a normal aggregate? A Also, the description calls the config Shuffle wire format changes
Title and description "fix: experiments with large types for aggregated values" is not a merge-ready title, and the body starts with an How was the fix validated? The original repro needs more than 2 GiB of interned group keys, which is not something a unit test can do. |
|
FYI here is a ticket upstream in DataFusion that explains the root cause |
204a4dc to
7db0b70
Compare
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: String/binary aggregation keys can overflow DataFusion’s 32-bit offsets once accumulated values exceed 2 GiB.
- Design approach: Promote grouping expressions to
LargeUtf8/LargeBinary, then narrow aggregate output throughSchemaAlignExec. - Correctness / compatibility analysis: Found three introduced issues below. Bounded tests reproduce a final-aggregation spill failure for both types. Small-data null/empty-key cases pass. Checked relevant Spark sources across 3.4.3, 3.5.9, 4.0.4, 4.1.3 and 4.2 development.
- Key design decisions: Keeping external schemas unchanged preserves Spark’s type contract, but final aggregation’s internal spill schema must also match promoted keys.
- Implementation sketch: Scala serializes the new flag, the native planner adds promotion and output alignment, and conversion/shuffle/FFI helpers accept large-offset types.
- Behavioral changes worth calling out: The feature defaults to enabled. Affected aggregates lose their reported metrics and copy all key bytes during narrowing, including ordinary batches below the overflow limit.
- Suggested improvements: Align final-aggregate input with its spill schema, retain aggregate metrics through the wrapper, and use buffer-sharing casts for batches whose offsets fit.
Reviewed the entire diff from 64e98918ab11c41089416b18b262f56f83d41344 to 7db0b70d8f394e129dac613c39f3c2ab2cfd715d. The PR remains open and non-draft. Read existing discussion and threads. The previously discussed no-op cast is absent at this head.
Routed skills: review-comet-pr, review-comet-expression-pr, review-comet-ffi-pr, review-comet-memory-pr, and review-comet-shuffle-pr.
Exact-head CI: All non-skipped checks passed, including Linux Rust tests, Spark 4.1 Comet suites, and TPC-H/TPC-DS. Spark SQL, Iceberg, macOS, and benchmark checks were skipped. The added large-data test was ignored.
Validation: A disposable harness used the exact SchemaAlignExec source with locked DataFusion 55.1.0 and Arrow 59.3.0. Its three existing tests passed. Additional bounded cases verified results, reproduced spill failures and missing metrics, and confirmed a projection-based correction. An optimized microbenchmark measured narrowing costs. No full Comet JVM/native rebuild, full Spark SQL/Iceberg run, or >2 GiB reproduction was performed. Benchmark timings cover conversion only. Project files remain unchanged.
| .map(|r| (r, format!("col_{idx}"))) | ||
| let raw = self.create_expr(expr, Arc::clone(&child_schema_ref))?; | ||
| let (wrapped, revert) = if use_large { | ||
| promote_byte_group_key(raw, child_schema)? |
There was a problem hiding this comment.
[P1] Keep the final aggregate’s input and spill schemas consistent with promoted keys. This promotion also runs in Final mode, while its child still produces Utf8/Binary. DataFusion 55.1.0 uses agg.input().schema() as the final aggregate’s state schema. When memory pressure triggers spilling, it stamps that small-offset schema onto promoted group arrays and fails with expected Utf8 but found LargeUtf8 (likewise for binary). The output alignment cannot fix an error inside the aggregate. With the default enabled, ordinary spilling GROUP BY/DISTINCT queries now fail. Promote the final input through a matching projection, or otherwise make its spill state schema agree with the promoted keys.
Evidence: Bounded reproduction in /tmp/comet-4791-validation/src/main.rs: native Final distinct aggregation over 80 batches of 256 rows, 10,000 distinct 128-byte keys, batch size 256, and a 1 MiB FairSpillPool. Without promotion, both Utf8 and Binary return 10,000 keys after five spills. With the PR’s grouping-expression promotion, both fail before returning rows with the corresponding small/large type mismatch. Promoting the input through ProjectionExec restores 10,000 results and five spills. DataFusion’s aggregate_hash_table/final_table.rs takes the state schema from agg.input().schema(), and common.rs::take_state_batch constructs the failing batch.
| }) | ||
| .collect(); | ||
| let target_schema: SchemaRef = Arc::new(Schema::new(target_fields)); | ||
| SchemaAlignExec::try_new_or_passthrough(aggregate, &target_schema) |
There was a problem hiding this comment.
[P2] Preserve aggregate metrics when installing this wrapper. SchemaAlignExec has no metrics() implementation, but it replaces AggregateExec as the native plan passed to SparkPlan::new. to_native_metric_node consequently reads no metrics and never visits the wrapped aggregate. Every promoted string/binary aggregation loses output-row, execution-time, and spill reporting, including the spill information propagated into Spark task metrics. Forward the aggregate’s metrics through the wrapper or explicitly retain it as the metric source.
Evidence: The exact-source harness executes string and binary aggregates containing duplicates, nulls, and empty keys. Both return four groups, and the underlying AggregateExec reports output_rows=4 and nonzero elapsed_compute, while the wrapper returns metrics=None. SparkPlan::new leaves additional_native_plans empty, and native/core/src/execution/metrics/utils.rs::to_native_metric_node only reads the root metrics in that case.
There was a problem hiding this comment.
I can confirm this end to end with a full build. SELECT k, sum(v) FROM t GROUP BY k on a string key shows the partial CometHashAggregateExec with output_rows=0 and elapsed_compute=0 when the flag is on, against 20000 rows and about 50 ms with it off. The final aggregate's elapsed_compute drops from about 10 ms to 17 µs. When fixing it, note that to_native_metric_node skips output_rows from additional_native_plans, so registering the AggregateExec through SparkPlan::new_with_additional also needs the wrapper to record its own output_rows. A test asserting these metrics on a string-keyed aggregate would catch this. The one in CometExecSuite uses a numeric key.
| // the underlying Vec never has to grow-and-memcpy while we replay rows. | ||
| let offsets = arr.value_offsets(); | ||
| let values_bytes = (offsets[arr.len()] - offsets[0]) as usize; | ||
| let mut builder = StringBuilder::with_capacity(arr.len(), values_bytes); |
There was a problem hiding this comment.
[P2] Avoid copying value buffers when the offsets already fit. This builder path runs for every promoted string key, with the equivalent copy for binary, even when no splitting or rebasing is needed. Since promotion defaults to enabled, each partial/final aggregation adds a full allocation and copy of its emitted key bytes. Arrow can narrow ordinary batches while sharing those bytes. Use that fast path when offsets fit, reserving rebuilding for overflow slices, or rebase offsets against a shared value-buffer slice.
Evidence: The exact SchemaAlignExec source copies a 4 MiB value buffer for 8,192 keys of 512 bytes, verified by buffer-pointer comparison. Arrow 59.3.0’s cast shares it and produces identical values. An optimized benchmark of the PR’s exact builder branch measured median conversion times of 168.1 µs versus 8.4 µs for that batch, and 7.81 ms versus 8.3 µs for 8,192 keys of 8 KiB. These are narrowing-only measurements, not whole-query timings. Reproduction: /tmp/comet-4791-cast-bench/src/main.rs.
andygrove
left a comment
There was a problem hiding this comment.
Keeping the Large types inside the aggregate is a good call. Shuffle blocks and the FFI boundary stay Utf8, which settles the rolling-upgrade question from my earlier review, and the TPC-H and TPC-DS result checks pass with the flag on. The title and body still describe an earlier design, though. The body mentions spark.comet.exec.useLargeDataTypes, a ShuffleWriter.use_large_data_types flag, changes to align_shuffle_writer_input and CometNativeShuffleWriter.scala, and Large types carried through shuffle with no cast-back. None of that is in the diff. Could the body be rewritten for the current head, along with the title change I suggested earlier?
| } | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| enum ColumnAction { |
There was a problem hiding this comment.
It looks like the rebase brought back the ColumnAction enum that #5138 removed from this file. #5138 routed SchemaAlignStream through cast_and_stamp_schema, so every batch is checked against the target schema and a failed cast names the operator and the column path. Here the choice is made once at plan time. Passthrough columns are no longer checked per batch, and the Cast arm returns a bare arrow error. This operator sits in front of every native shuffle writer, so that applies with the flag off too. Could the Large-to-small columns be handled first, with the result then handed to cast_and_stamp_schema?
| } | ||
| true | ||
| match (actual_field.data_type(), expected_field.data_type()) { | ||
| (DataType::LargeUtf8, DataType::Utf8) => ColumnAction::CastLargeStringToString, |
There was a problem hiding this comment.
With the flag on, reusing SchemaAlignExec above the aggregate hits the warning just above and logs ShuffleWriter input schema mismatch on col[0] 'col_0': child produced LargeUtf8, catalyst declared Utf8. Inserting a cast; please file the upstream function bug at .../issues/4515 on each executor. I saw it in my local runs. There is no shuffle writer or upstream bug involved here, and the module doc says this operator is enclosed by shuffle on purpose. Would a small operator dedicated to the cast-back be cleaner? It could record its own metrics, which would also help with the metrics problem sunchao raised in planner.rs.
| "because the cap is only reachable for very large per-partition group cardinalities; " + | ||
| "enable it when you see the offset-overflow error.") | ||
| .booleanConf | ||
| .createWithDefault(true) |
There was a problem hiding this comment.
I asked in July whether defaulting this to true was intentional, and the doc string still says it defaults to false. I measured it on a release build. SELECT count(*), sum(s) FROM (SELECT k, sum(v) s FROM t GROUP BY k) over 8M rows with 4M distinct 21-byte keys took 494 ms at the median with the flag on and 450 ms with it off, about 10% slower over five alternating runs. Low-cardinality and 200-byte keys were within noise. My laptop was busy, so treat the numbers as rough. The overflow needs more than 2 GiB of distinct key bytes in one task, and apache/datafusion#24704 tracks the real fix. Could this default to false, with the tuning guide pointing at it for the offset overflow error? Either way the doc needs updating. It describes the cast-back as a Projection and says the overhead is O(rows), but as sunchao pointed out, the cast-back copies every key byte.
| .createWithDefault(false) | ||
|
|
||
| val COMET_AGG_USE_LARGE_DATATYPES: ConfigEntry[Boolean] = | ||
| conf(s"$COMET_EXEC_CONFIG_PREFIX.aggregation.useLargeDataTypes") |
There was a problem hiding this comment.
The other aggregate keys use spark.comet.exec.aggregate.*, and config_conventions.md asks for feature flags to end in .enabled. This key will be covered by the versioning policy once it ships, so could we settle on something like spark.comet.exec.aggregate.largeGroupKeys.enabled now?
| // Native shuffle may dictionary-encode string/binary columns for efficiency, | ||
| // but downstream DataFusion operators expect the value types declared in the | ||
| // schema (e.g. Utf8, not Dictionary<Int32, Utf8>). | ||
| // Coerce each decoded column to the catalyst-declared type: |
There was a problem hiding this comment.
Since the aggregate now casts back before anything above it sees the batch, I don't think this feature can put LargeUtf8 into a shuffle block. If one did, the remote read path would reject it, because remote_schema.rs treats LargeUtf8 against Utf8 as an incompatible type. On the local path, ShuffleScanStream::poll_next already reconciles every column through cast_and_stamp_schema, so the extra cast here duplicates that. Could this go back to unpacking dictionaries only? Returning an error instead of the old expect is a good change and worth keeping.
| valueVector match { | ||
| case v if isSupportedFieldVector(v) => | ||
| v.asInstanceOf[FieldVector] | ||
| // Accepted here but left out of isSupportedFieldVector, which isArrowBacked uses to keep |
There was a problem hiding this comment.
Which path needs getFieldVector to accept LargeVarCharVector now that the aggregate casts back natively? As far as I can tell, the only Large vectors on the JVM side come from PyArrow UDFs returning large_string. So this changes native C2R export and broadcast serialization for that path without a test. It also makes the isSupportedFieldVector doc, the isArrowBacked comment and the UtilsSuite test comment wrong, since all three assume getFieldVector rejects these vectors. Could this be dropped here, or moved to its own PR with a PyArrow test? The same question applies to the LargeUtf8 passthrough at columnar_to_row.rs:1041.
| matches!(to_type, DataType::Binary | DataType::LargeUtf8) | ||
| } | ||
|
|
||
| pub(crate) fn is_df_cast_from_large_string_spark_compatible(to_type: &DataType) -> bool { |
There was a problem hiding this comment.
I can't find a way to reach these new arms, or the matching ones in cast.rs. types.proto has no Large type ids, so the serde never emits a Cast to or from one. The Parquet schema adapter's casts already go through the is_adapting_schema branch in cast_array. The comment also says SchemaAlignExec pre-splits arrays before they get here, but SchemaAlignExec builds its arrays directly and never calls this cast. Could these arms be removed from this PR?
| // The test to reproduce `offset overflow` for aggregation queries, when interim data | ||
| // get exploded 100x comparing to initial input size. | ||
| // It is not supposed to run on CI as the test requires significant RAM to succeed | ||
| ignore("CUBE(9) + COUNT(DISTINCT) wide Utf8 keys: useLargeDataTypes preserves correctness") { |
There was a problem hiding this comment.
This test is ignored, so CI never runs it. The sparkConf comment at line 60 still describes the off-heap and memory pool settings that 7252adb removed. This test now sets the pool through withSQLConf, which that comment says won't take effect. You mentioned in July that CI passes with the flag on. That covers the common path, but nothing exercises the split in compute_row_ranges or checks the metrics. Could the byte cap be a parameter so a Rust unit test can split a small batch? And could this test and the stale comment be replaced with small tests that run? If the default moves to false, those tests would also need to turn the flag on explicitly.
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: High-cardinality string/binary grouping can exceed DataFusion’s 2 GiB group-key buffer limit.
- Design approach: Promote keys to
LargeUtf8/LargeBinaryinside aggregation, then narrow output throughSchemaAlignExec. - Correctness / compatibility analysis: Small null, empty-key, and duplicate-key cases pass. The existing P1 spill failure remains reproducible for both types with 10,000 keys and a 1 MiB pool. Compared relevant Spark sources across 3.4.3, 3.5.9, 4.0.4, 4.1.3, and 4.2 development.
- Key design decisions: Keeping external schemas unchanged preserves Spark’s type contract. Reusing the shuffle alignment wrapper introduces the already-reported metrics and abstraction concerns.
- Implementation sketch: Scala serializes the configuration flag. The native planner promotes grouping expressions and aligns aggregate output. Shuffle, cast, and FFI helpers gain large-offset handling.
- Behavioral changes worth calling out: Promotion defaults to enabled. Independently reproduced the existing missing-metrics concern and confirmed that narrowing copies a 4 MiB value buffer which Arrow’s ordinary cast shares.
- Suggested improvements: Resolve the existing spill-schema, aggregate-metrics, and unnecessary-copy threads before merging. No additional introduced P1/P2 issues found within this review.
Reviewed the entire 11-file diff from 64e98918ab11c41089416b18b262f56f83d41344 to 7db0b70d8f394e129dac613c39f3c2ab2cfd715d, including surrounding code and existing discussion. The PR remains non-draft. Existing findings are not duplicated below.
Routed skills: review-comet-pr, review-comet-expression-pr, review-comet-ffi-pr, review-comet-memory-pr, and review-comet-shuffle-pr.
Exact-head CI: All executed checks passed, including Linux Rust tests, Spark 4.1 Comet suites, and TPC-H/TPC-DS result checks. Spark SQL, Iceberg, PyArrow UDF, macOS, and benchmark jobs were skipped.
Validation: Reran the disposable harness against the exact SchemaAlignExec source with DataFusion 55.1.0 and Arrow 59.3.0. Its 19 tests passed, including additional splitter tests using a reduced 32-byte cap to exercise multiple columns, sliced offsets, nulls, empty batches, and oversized-row rejection. No full Comet JVM/native rebuild, full Spark SQL/Iceberg run, or actual >2 GiB validation was performed. Project files and GitHub state were unchanged.
|
Final aggregates fail on spill with A job running this branch (rebased on Cause:
Suggested fix: for DataFusion Two planner tests cover it:
Both tests pass with the fix. With Workaround until then: Two more things on this branch:
|
DataFusion's final hash aggregation spills against its input schema (`AggregateHashTable<FinalMarker>` takes `agg.input().schema()`), so casting a Final group-by expression to LargeUtf8/LargeBinary made the first spill fail with "column types must match schema types, expected Binary but found LargeBinary at column index 0". For Final aggregates, cast the key columns in a pass-through projection below the aggregate instead, so its input, group values and spill files share one type. Partial and PartialMerge keep the expression cast. Adds a plan-shape test and a spill test. Both fail with the original error when Final aggregates take the expression cast.
Which issue does this PR close?
Closes #4718 .
Support LargeUtf8/LargeBinary group keys in HashAggregate to bypass the 2 GiB offset cap
Problem
CUBE/GROUPING SETS+COUNT(DISTINCT wide_string)workloads with high group-key cardinality trip DataFusion's per-taskByteGroupValueBuilder<i32>byte-buffer cap (i32::MAX = 2 147 483 647),surfacing as:
The cap is per-column per-task on the group-key accumulator, hit when the cumulative bytes of one Utf8 column across all interned distinct group tuples exceed 2 GiB.
Fix
Add a new config
spark.comet.exec.useLargeDataTypes(defaulttrue) that promotes Utf8/Binary group-by expressions to LargeUtf8/LargeBinary before the aggregate, routing DataFusion toByteGroupValueBuilder<i64>(i64 offsets, effectively unbounded buffer). The Large variant is preserved end-to-end through shuffle and mapped back to SparkStringTypeat the JVM boundary — no cast-backprojection, no lossy round-trip.
Changes
Rust
native/proto/src/proto/operator.proto— addedHashAggregate.use_large_data_typesandShuffleWriter.use_large_data_typesflags.native/core/src/execution/planner.rspromote_byte_group_keywraps each group-by expr inCastExpr(LargeUtf8|LargeBinary)when the flag is on.align_shuffle_writer_inputaccepts the flag and promotesUtf8/Binaryinexpected_output_schemato their Large variants before invokingSchemaAlignExec, so no down-cast is ever inserted.native/core/src/execution/columnar_to_row.rs—maybe_cast_to_schema_typepassesLargeUtf8/LargeBinarythrough unchanged (the row encoder'sTypedArray::LargeString/LargeBinaryvariants alreadyhandle both offset widths).
native/shuffle/src/schema_align.rs— newCastLargeStringToString/CastLargeBinaryToBinaryactions with a byte-aware row-range splitter that rebuilds each chunk viaStringBuilder/BinaryBuilder(arrow's
cast_byte_containerfails on sliced offsets, so we can't rely oncast_with_optionsalone).native/spark-expr/src/conversion_funcs/{cast,string}.rs— extendedis_datafusion_spark_compatibleto whitelist all four offset-width conversions (Utf8↔LargeUtf8,Binary↔LargeBinary), safety-net forany DF adapter that still constructs
spark_expr::Castfor these types.Scala / Java
spark/.../CometConf.scala— addedCOMET_AGG_USE_LARGE_DATATYPESwith explanatory doc.spark/.../operators.scala— wires the flag into bothHashAggregate.newBuilder()sites viaCometConf.COMET_AGG_USE_LARGE_DATATYPES.get(aggregate.conf).spark/.../CometNativeShuffleWriter.scala— wires the flag intoShuffleWriter.newBuilder().spark/.../comet/util/Utils.scala— mapsLargeUtf8 → StringType/LargeBinary → BinaryType; addsLargeVarCharVector/LargeVarBinaryVectorto the FFI export whitelist.Test coverage
CometAggregateSuitegains one test that runs the same CUBE(9) +COUNT(DISTINCT)shape twice:useLargeDataTypes=falseon 30K × 384B rows → asserts the"offset overflow"exception (existing behavior preserved).useLargeDataTypes=trueon 12K × 170B rows →checkSparkAnswerAndOperatorvalidates row-by-row equality against the Comet-disabled Spark baseline and re-assertsCometHashAggregateExecpresence.Residual limits
LargeUtf8 → Utf8casts elsewhere in the pipeline still have to fit a single arrow-Utf8 batch (i32::MAXbytes).SchemaAlignExecsplits by byte budget to stay under it; for extreme per-batch bytes thiscan still fail — mitigated by lowering
datafusion.execution.batch_sizefor the aggregate subtree if needed.flowchart TD Scan["CometNativeScan parquet<br/><b>Utf8</b> (i32 offsets, ≤ 2 GiB per batch)"] Filter["CometFilter · CometProject · CometExpand<br/><b>Utf8</b> passthrough"] subgraph Agg["CometHashAggregate <i>(useLargeDataTypes=true)</i>"] direction TB Cast["CastExpr(Utf8 → LargeUtf8)<br/><i>promote_byte_group_key</i><br/><b>widen offsets i32 → i64</b>"] AggCore["AggregateExec (Partial / PartialMerge / Final)<br/>PhysicalGroupBy sees <b>LargeUtf8</b> keys<br/>→ dispatch ByteGroupValueBuilder<i64><br/>buffer cap = i64::MAX (unbounded)"] Cast --> AggCore end subgraph Shuffle["CometExchange / CometNativeShuffleWriter"] direction TB Align["align_shuffle_writer_input<br/>promote expected_output_schema<br/><b>Utf8 → LargeUtf8</b> per <i>use_large_data_types</i>"] SchemaAlign["SchemaAlignExec<br/><b>passthrough</b> (no cast)"] IPC["Shuffle blocks encoded as <b>LargeUtf8</b>"] Align --> SchemaAlign --> IPC end subgraph JVMSide["JVM boundary"] direction TB Import["NativeUtil.importVector →<br/><b>LargeVarCharVector</b>"] TypeMap["Utils.fromArrowType<br/><b>LargeUtf8 → StringType</b>"] Import --> TypeMap end Downstream["Downstream CometHashAggregate<br/>re-promotion is a no-op<br/>(child schema already LargeUtf8)"] C2R["CometNativeColumnarToRow<br/>maybe_cast_to_schema_type:<br/><b>(LargeUtf8, Utf8) → passthrough</b><br/>TypedArray::LargeString → UnsafeRow"] Spark["Spark UnsafeRow (byte-oriented,<br/>offset width irrelevant)"] Scan --> Filter --> Agg --> Shuffle --> JVMSide --> Downstream --> C2R --> Spark style Cast fill:#fef3c7,stroke:#d97706 style AggCore fill:#dbeafe,stroke:#2563eb style Align fill:#fef3c7,stroke:#d97706 style SchemaAlign fill:#dcfce7,stroke:#16a34a style TypeMap fill:#fef3c7,stroke:#d97706 style C2R fill:#dcfce7,stroke:#16a34aLegend: